Repository: VincentLanglet/Twig-CS-Fixer (php)

Issue:
Remove excess spacing before multiline block `%}`
### Rule(s) related to or rule(s) missing

Using the standard TwigCsFixer rules I notice something odd.

### Actual behavior

```twig
{% trans 'my.key' with {
    faqUrl: 'http://faq',
    contactUrl: 'http://contact',
    }

%}
```

### Expected behavior

```twig
{% trans 'my.key' with {
    faqUrl: 'http://faq',
    contactUrl: 'http://contact',
    } %}
```

Why isn't the excess newline and spacing not removed?

Pull request:
Remove excess spacing before multiline block
Fixes #245


Patch:
diff --git a/docs/configuration.md b/docs/configuration.md
index cd4b695..f6fada5 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -115,7 +115,7 @@ $config->setCacheFile(null);
 return $config;
 ```
 
-## Token parser & Twig Extension
+## Token parser, Twig Extension & Node Visitors
 
 If you're using custom token parsers or binary/unary operators, they can be added in your config:
 
@@ -125,6 +125,7 @@ If you're using custom token parsers or binary/unary operators, they can be adde
 $config = new TwigCsFixer\Config\Config();
 $config->addTwigExtension(new App\Twig\CustomTwigExtension());
 $config->addTokenParser(new App\Twig\CustomTokenParser());
+$config->addNodeVisitor(new App\Twig\CustomNodeVisitor());
 
 return $config;
 ```
diff --git a/src/Command/TwigCsFixerCommand.php b/src/Command/TwigCsFixerCommand.php
index 20343c7..4b5f070 100644
--- a/src/Command/TwigCsFixerCommand.php
+++ b/src/Command/TwigCsFixerCommand.php
@@ -129,7 +129,8 @@ final class TwigCsFixerCommand extends Command
     {
         $twig = new StubbedEnvironment(
             $config->getTwigExtensions(),
-            $config->getTokenParsers()
+            $config->getTokenParsers(),
+            $config->getNodeVisitors()
         );
         $tokenizer = new Tokenizer($twig);
         $linter = new Linter($twig, $tokenizer, $config->getCacheManager());
diff --git a/src/Config/Config.php b/src/Config/Config.php
index 95f2b8d..06cbcd2 100644
--- a/src/Config/Config.php
+++ b/src/Config/Config.php
@@ -6,6 +6,7 @@ namespace TwigCsFixer\Config;
 
 use Symfony\Component\Finder\Finder;
 use Twig\Extension\ExtensionInterface;
+use Twig\NodeVisitor\NodeVisitorInterface;
 use Twig\TokenParser\TokenParserInterface;
 use TwigCsFixer\Cache\Manager\CacheManagerInterface;
 use TwigCsFixer\File\Finder as TwigCsFinder;
@@ -39,6 +40,11 @@ final class Config
      */
     private array $tokenParsers = [];
 
+    /**
+     * @var list<NodeVisitorInterface>
+     */
+    private array $nodeVisitors = [];
+
     private bool $allowNonFixableRules = false;
 
     public function __construct(private string $name = 'Default')
@@ -146,6 +152,24 @@ final class Config
         return $this->tokenParsers;
     }
 
+    /**
+     * @return $this
+     */
+    public function addNodeVisitor(NodeVisitorInterface $nodeVisitor): self
+    {
+        $this->nodeVisitors[] = $nodeVisitor;
+
+        return $this;
+    }
+
+    /**
+     * @return list<NodeVisitorInterface>
+     */
+    public function getNodeVisitors(): array
+    {
+        return $this->nodeVisitors;
+    }
+
     /**
      * @return $this
      */
diff --git a/src/Environment/StubbedEnvironment.php b/src/Environment/StubbedEnvironment.php
index 87763e5..ae8bd84 100644
--- a/src/Environment/StubbedEnvironment.php
+++ b/src/Environment/StubbedEnvironment.php
@@ -16,6 +16,7 @@ use Twig\Environment;
 use Twig\Extension\ExtensionInterface;
 use Twig\Extra\Cache\TokenParser\CacheTokenParser;
 use Twig\Loader\ArrayLoader;
+use Twig\NodeVisitor\NodeVisitorInterface;
 use Twig\TokenParser\TokenParserInterface;
 use Twig\TwigFilter;
 use Twig\TwigFunction;
@@ -48,10 +49,12 @@ final class StubbedEnvironment extends Environment
     /**
      * @param ExtensionInterface[]   $customTwigExtensions
      * @param TokenParserInterface[] $customTokenParsers
+     * @param NodeVisitorInterface[] $customNodeVisitors
      */
     public function __construct(
         array $customTwigExtensions = [],
-        array $customTokenParsers = []
+        array $customTokenParsers = [],
+        array $customNodeVisitors = [],
     ) {
         parent::__construct(new ArrayLoader());
 
@@ -64,6 +67,10 @@ final class StubbedEnvironment extends Environment
         foreach ($customTokenParsers as $customTokenParser) {
             $this->addTokenParser($customTokenParser);
         }
+
+        foreach ($customNodeVisitors as $customNodeVisitor) {
+            $this->addNodeVisitor($customNodeVisitor);
+        }
     }
 
     /**
diff --git a/src/Rules/AbstractSpacingRule.php b/src/Rules/AbstractSpacingRule.php
index 8243a90..5779a82 100644
--- a/src/Rules/AbstractSpacingRule.php
+++ b/src/Rules/AbstractSpacingRule.php
@@ -11,6 +11,8 @@ use TwigCsFixer\Token\Token;
  */
 abstract class AbstractSpacingRule extends AbstractFixableRule
 {
+    protected bool $skipIfNewLine = true;
+
     protected function process(int $tokenPosition, array $tokens): void
     {
         $spaceAfter = $this->getSpaceAfter($tokenPosition, $tokens);
@@ -42,24 +44,29 @@ abstract class AbstractSpacingRule extends AbstractFixableRule
     {
         $token = $tokens[$tokenPosition];
 
-        // Ignore new line
         $next = $this->findNext(Token::INDENT_TOKENS, $tokens, $tokenPosition + 1, true);
-        if (false === $next || $this->isTokenMatching($tokens[$next], Token::EOL_TOKENS)) {
+        if (false === $next) {
             return;
         }
 
-        if ($this->isTokenMatching($tokens[$tokenPosition + 1], Token::WHITESPACE_TOKENS)) {
-            $count = \strlen($tokens[$tokenPosition + 1]->getValue());
+        if ($this->isTokenMatching($tokens[$next], Token::EOL_TOKENS)) {
+            if ($this->skipIfNewLine) {
+                return;
+            }
+
+            $found = 'newline';
+        } elseif ($this->isTokenMatching($tokens[$tokenPosition + 1], Token::WHITESPACE_TOKENS)) {
+            $found = \strlen($tokens[$tokenPosition + 1]->getValue());
         } else {
-            $count = 0;
+            $found = 0;
         }
 
-        if ($expected === $count) {
+        if ($expected === $found) {
             return;
         }
 
         $fixer = $this->addFixableError(
-            sprintf('Expecting %d whitespace after "%s"; found %d', $expected, $token->getValue(), $count),
+            sprintf('Expecting %d whitespace after "%s"; found %s.', $expected, $token->getValue(), $found),
             $token,
             'After'
         );
@@ -68,11 +75,21 @@ abstract class AbstractSpacingRule extends AbstractFixableRule
             return;
         }
 
-        if (0 === $count) {
-            $fixer->addContent($tokenPosition, str_repeat(' ', $expected));
-        } else {
-            $fixer->replaceToken($tokenPosition + 1, str_repeat(' ', $expected));
+        $index = $tokenPosition + 1;
+        $tokensToReplace = $this->skipIfNewLine
+            ? Token::INDENT_TOKENS
+            : Token::INDENT_TOKENS + Token::EOL_TOKENS;
+
+        $fixer->beginChangeSet();
+        while (
+            isset($tokens[$index])
+            && $this->isTokenMatching($tokens[$index], $tokensToReplace)
+        ) {
+            $fixer->replaceToken($index, '');
+            ++$index;
         }
+        $fixer->addContent($tokenPosition, str_repeat(' ', $expected));
+        $fixer->endChangeSet();
     }
 
     /**
@@ -82,24 +99,29 @@ abstract class AbstractSpacingRule extends AbstractFixableRule
     {
         $token = $tokens[$tokenPosition];
 
-        // Ignore new line
         $previous = $this->findPrevious(Token::INDENT_TOKENS, $tokens, $tokenPosition - 1, true);
-        if (false === $previous || $this->isTokenMatching($tokens[$previous], Token::EOL_TOKENS)) {
+        if (false === $previous) {
             return;
         }
 
-        if ($this->isTokenMatching($tokens[$tokenPosition - 1], Token::WHITESPACE_TOKENS)) {
-            $count = \strlen($tokens[$tokenPosition - 1]->getValue());
+        if ($this->isTokenMatching($tokens[$previous], Token::EOL_TOKENS)) {
+            if ($this->skipIfNewLine) {
+                return;
+            }
+
+            $found = 'newline';
+        } elseif ($this->isTokenMatching($tokens[$tokenPosition - 1], Token::WHITESPACE_TOKENS)) {
+            $found = \strlen($tokens[$tokenPosition - 1]->getValue());
         } else {
-            $count = 0;
+            $found = 0;
         }
 
-        if ($expected === $count) {
+        if ($expected === $found) {
             return;
         }
 
         $fixer = $this->addFixableError(
-            sprintf('Expecting %d whitespace before "%s"; found %d', $expected, $token->getValue(), $count),
+            sprintf('Expecting %d whitespace before "%s"; found %s.', $expected, $token->getValue(), $found),
             $token,
             'Before'
         );
@@ -108,10 +130,20 @@ abstract class AbstractSpacingRule extends AbstractFixableRule
             return;
         }
 
-        if (0 === $count) {
-            $fixer->addContentBefore($tokenPosition, str_repeat(' ', $expected));
-        } else {
-            $fixer->replaceToken($tokenPosition - 1, str_repeat(' ', $expected));
+        $index = $tokenPosition - 1;
+        $tokensToReplace = $this->skipIfNewLine
+            ? Token::INDENT_TOKENS
+            : Token::INDENT_TOKENS + Token::EOL_TOKENS;
+
+        $fixer->beginChangeSet();
+        while (
+            isset($tokens[$index])
+            && $this->isTokenMatching($tokens[$index], $tokensToReplace)
+        ) {
+            $fixer->replaceToken($index, '');
+            --$index;
         }
+        $fixer->addContentBefore($tokenPosition, str_repeat(' ', $expected));
+        $fixer->endChangeSet();
     }
 }
diff --git a/src/Rules/Delimiter/DelimiterSpacingRule.php b/src/Rules/Delimiter/DelimiterSpacingRule.php
index e1e7572..293a9a1 100644
--- a/src/Rules/Delimiter/DelimiterSpacingRule.php
+++ b/src/Rules/Delimiter/DelimiterSpacingRule.php
@@ -5,13 +5,26 @@ declare(strict_types=1);
 namespace TwigCsFixer\Rules\Delimiter;
 
 use TwigCsFixer\Rules\AbstractSpacingRule;
+use TwigCsFixer\Rules\ConfigurableRuleInterface;
 use TwigCsFixer\Token\Token;
 
 /**
  * Ensures there is one space before '}}', '%}' and '#}', and after '{{', '{%', '{#'.
  */
-final class DelimiterSpacingRule extends AbstractSpacingRule
+final class DelimiterSpacingRule extends AbstractSpacingRule implements ConfigurableRuleInterface
 {
+    public function __construct(bool $skipIfNewLine = true)
+    {
+        $this->skipIfNewLine = $skipIfNewLine;
+    }
+
+    public function getConfiguration(): array
+    {
+        return [
+            'skipIfNewLine' => $this->skipIfNewLine,
+        ];
+    }
+
     /**
      * @param array<int, Token> $tokens
      */


Test patch:
diff --git a/tests/Config/ConfigTest.php b/tests/Config/ConfigTest.php
index 21b7d90..d917b9b 100644
--- a/tests/Config/ConfigTest.php
+++ b/tests/Config/ConfigTest.php
@@ -5,6 +5,7 @@ declare(strict_types=1);
 namespace TwigCsFixer\Tests\Config;
 
 use PHPUnit\Framework\TestCase;
+use Symfony\Bridge\Twig\NodeVisitor\TranslationNodeVisitor;
 use Symfony\Bridge\Twig\TokenParser\DumpTokenParser;
 use Symfony\Bridge\Twig\TokenParser\TransTokenParser;
 use Symfony\Component\Finder\Finder;
@@ -86,6 +87,18 @@ final class ConfigTest extends TestCase
         static::assertSame([$tokenParser1, $tokenParser2], $config->getTokenParsers());
     }
 
+    public function testConfigNodeVisitors(): void
+    {
+        $config = new Config();
+
+        static::assertSame([], $config->getNodeVisitors());
+
+        $nodeVisitor1 = new TranslationNodeVisitor();
+        $config->addNodeVisitor($nodeVisitor1);
+
+        static::assertSame([$nodeVisitor1], $config->getNodeVisitors());
+    }
+
     public function testConfigTwigExtensions(): void
     {
         $config = new Config();
diff --git a/tests/Environment/Fixtures/node_visitor.html.twig b/tests/Environment/Fixtures/node_visitor.html.twig
new file mode 100644
index 0000000..2aa301b
--- /dev/null
+++ b/tests/Environment/Fixtures/node_visitor.html.twig
@@ -0,0 +1,1 @@
+{{ 'Hello, World!' | upper }}
diff --git a/tests/Environment/StubbedEnvironmentTest.php b/tests/Environment/StubbedEnvironmentTest.php
index c6a70fb..4cec7a6 100644
--- a/tests/Environment/StubbedEnvironmentTest.php
+++ b/tests/Environment/StubbedEnvironmentTest.php
@@ -12,7 +12,10 @@ use Symfony\Bridge\Twig\Node\FormThemeNode;
 use Symfony\Bridge\Twig\Node\StopwatchNode;
 use Symfony\Bridge\Twig\Node\TransDefaultDomainNode;
 use Symfony\Bridge\Twig\Node\TransNode;
+use Twig\Environment;
+use Twig\Node\Node;
 use Twig\Node\TextNode;
+use Twig\NodeVisitor\NodeVisitorInterface;
 use Twig\Source;
 use Twig\TwigFilter;
 use Twig\TwigFunction;
@@ -177,6 +180,39 @@ final class StubbedEnvironmentTest extends TestCase
         $env->parse($env->tokenize($source));
     }
 
+    public function testParseWithCustomNodeVisitor(): void
+    {
+        $content = file_get_contents(__DIR__.'/Fixtures/node_visitor.html.twig');
+        static::assertNotFalse($content);
+
+        $visitor = new class() implements NodeVisitorInterface {
+            public int $called = 0;
+
+            public function enterNode(Node $node, Environment $env): Node
+            {
+                ++$this->called;
+
+                return $node;
+            }
+
+            public function leaveNode(Node $node, Environment $env): Node
+            {
+                return $node;
+            }
+
+            public function getPriority(): int
+            {
+                return 0;
+            }
+        };
+        $env = new StubbedEnvironment([], [], [$visitor]);
+        $source = new Source($content, 'node_visitor.html.twig');
+
+        $env->parse($env->tokenize($source));
+
+        static::assertSame(23, $visitor->called);
+    }
+
     public function testParseWithCustomTwigExtension(): void
     {
         $content = file_get_contents(__DIR__.'/Fixtures/custom_tags.html.twig');
diff --git a/tests/Rules/Delimiter/BlockNameSpacing/BlockNameSpacingRuleTest.php b/tests/Rules/Delimiter/BlockNameSpacing/BlockNameSpacingRuleTest.php
index 7a30001..f683f2a 100644
--- a/tests/Rules/Delimiter/BlockNameSpacing/BlockNameSpacingRuleTest.php
+++ b/tests/Rules/Delimiter/BlockNameSpacing/BlockNameSpacingRuleTest.php
@@ -12,10 +12,10 @@ final class BlockNameSpacingRuleTest extends AbstractRuleTestCase
     public function testRule(): void
     {
         $this->checkRule(new BlockNameSpacingRule(), [
-            'BlockNameSpacing.After:1:5' => 'Expecting 1 whitespace after "extends"; found 0',
-            'BlockNameSpacing.Before:1:5' => 'Expecting 1 whitespace before "extends"; found 2',
-            'BlockNameSpacing.After:3:3' => 'Expecting 1 whitespace after "if"; found 4',
-            'BlockNameSpacing.Before:3:3' => 'Expecting 1 whitespace before "if"; found 0',
+            'BlockNameSpacing.After:1:5' => 'Expecting 1 whitespace after "extends"; found 0.',
+            'BlockNameSpacing.Before:1:5' => 'Expecting 1 whitespace before "extends"; found 2.',
+            'BlockNameSpacing.After:3:3' => 'Expecting 1 whitespace after "if"; found 4.',
+            'BlockNameSpacing.Before:3:3' => 'Expecting 1 whitespace before "if"; found 0.',
         ]);
     }
 }
diff --git a/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.fixed.twig b/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.fixed.twig
index 3994d16..d88b166 100644
--- a/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.fixed.twig
+++ b/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.fixed.twig
@@ -29,3 +29,11 @@
     {% endfor %}
     </ul>
 {% endverbatim %}
+
+{%
+
+    trans with {
+    contactUrl: 'http://contact',
+}
+
+ %}my.key{% endtrans %}
diff --git a/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.fixed2.twig b/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.fixed2.twig
new file mode 100644
index 0000000..cdf6ad3
--- /dev/null
+++ b/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.fixed2.twig
@@ -0,0 +1,29 @@
+{{ foo }}
+{# comment #}
+{% if foo %}{% endif %}
+
+{{- foo -}}
+{#- comment -#}
+{%- if foo -%}{%- endif -%}
+
+{{ shouldNotCareAboutNewLine }}
+{# shouldNotCareAboutNewLine #}
+{%- if foo -%}{%- endif -%}
+
+{{ foo({'bar': {'baz': 'shouldNotCareAboutDoubleHashes'}}) }}
+
+<div>
+    {{ shouldNotCareAboutNewLine }}
+</div>
+
+{% verbatim %}
+    <ul>
+    {% for item in seq %}
+        <li>{{item}}</li>
+    {% endfor %}
+    </ul>
+{% endverbatim %}
+
+{% trans with {
+    contactUrl: 'http://contact',
+} %}my.key{% endtrans %}
diff --git a/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.php b/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.php
index 381d638..26c976d 100644
--- a/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.php
+++ b/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.php
@@ -9,13 +9,43 @@ use TwigCsFixer\Tests\Rules\AbstractRuleTestCase;
 
 final class DelimiterSpacingRuleTest extends AbstractRuleTestCase
 {
+    public function testConfiguration(): void
+    {
+        static::assertSame(
+            ['skipIfNewLine' => true],
+            (new DelimiterSpacingRule())->getConfiguration()
+        );
+        static::assertSame(
+            ['skipIfNewLine' => false],
+            (new DelimiterSpacingRule(false))->getConfiguration()
+        );
+    }
+
     public function testRule(): void
     {
         $this->checkRule(new DelimiterSpacingRule(), [
-            'DelimiterSpacing.After:15:1' => 'Expecting 1 whitespace after "{%-"; found 0',
-            'DelimiterSpacing.Before:15:12' => 'Expecting 1 whitespace before "-%}"; found 2',
-            'DelimiterSpacing.After:15:15' => 'Expecting 1 whitespace after "{%-"; found 2',
-            'DelimiterSpacing.Before:15:25' => 'Expecting 1 whitespace before "-%}"; found 0',
+            'DelimiterSpacing.After:15:1' => 'Expecting 1 whitespace after "{%-"; found 0.',
+            'DelimiterSpacing.Before:15:12' => 'Expecting 1 whitespace before "-%}"; found 2.',
+            'DelimiterSpacing.After:15:15' => 'Expecting 1 whitespace after "{%-"; found 2.',
+            'DelimiterSpacing.Before:15:25' => 'Expecting 1 whitespace before "-%}"; found 0.',
         ]);
     }
+
+    public function testRuleWithoutSkipIfNewLine(): void
+    {
+        $this->checkRule(new DelimiterSpacingRule(false), [
+            'DelimiterSpacing.After:9:1' => 'Expecting 1 whitespace after "{{"; found newline.',
+            'DelimiterSpacing.Before:11:1' => 'Expecting 1 whitespace before "}}"; found newline.',
+            'DelimiterSpacing.After:12:1' => 'Expecting 1 whitespace after "{#"; found newline.',
+            'DelimiterSpacing.Before:14:1' => 'Expecting 1 whitespace before "#}"; found newline.',
+            'DelimiterSpacing.After:15:1' => 'Expecting 1 whitespace after "{%-"; found 0.',
+            'DelimiterSpacing.Before:15:12' => 'Expecting 1 whitespace before "-%}"; found 2.',
+            'DelimiterSpacing.After:15:15' => 'Expecting 1 whitespace after "{%-"; found 2.',
+            'DelimiterSpacing.Before:15:25' => 'Expecting 1 whitespace before "-%}"; found 0.',
+            'DelimiterSpacing.After:20:5' => 'Expecting 1 whitespace after "{{"; found newline.',
+            'DelimiterSpacing.Before:22:5' => 'Expecting 1 whitespace before "}}"; found newline.',
+            'DelimiterSpacing.After:33:1' => 'Expecting 1 whitespace after "{%"; found newline.',
+            'DelimiterSpacing.Before:39:2' => 'Expecting 1 whitespace before "%}"; found newline.',
+        ], fixedFilePath: __DIR__.'/DelimiterSpacingRuleTest.fixed2.twig');
+    }
 }
diff --git a/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.twig b/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.twig
index 04eb469..97ddbdb 100644
--- a/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.twig
+++ b/tests/Rules/Delimiter/DelimiterSpacing/DelimiterSpacingRuleTest.twig
@@ -29,3 +29,11 @@
     {% endfor %}
     </ul>
 {% endverbatim %}
+
+{%
+
+    trans with {
+    contactUrl: 'http://contact',
+}
+
+ %}my.key{% endtrans %}
diff --git a/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.php b/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.php
index 34d0aeb..47a25e2 100644
--- a/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.php
+++ b/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.php
@@ -12,50 +12,50 @@ final class OperatorSpacingRuleTest extends AbstractRuleTestCase
     public function testRule(): void
     {
         $this->checkRule(new OperatorSpacingRule(), [
-            'OperatorSpacing.After:1:5' => 'Expecting 1 whitespace after "+"; found 0',
-            'OperatorSpacing.Before:1:5' => 'Expecting 1 whitespace before "+"; found 0',
-            'OperatorSpacing.After:2:5' => 'Expecting 1 whitespace after "-"; found 0',
-            'OperatorSpacing.Before:2:5' => 'Expecting 1 whitespace before "-"; found 0',
-            'OperatorSpacing.After:3:5' => 'Expecting 1 whitespace after "/"; found 0',
-            'OperatorSpacing.Before:3:5' => 'Expecting 1 whitespace before "/"; found 0',
-            'OperatorSpacing.After:4:5' => 'Expecting 1 whitespace after "*"; found 0',
-            'OperatorSpacing.Before:4:5' => 'Expecting 1 whitespace before "*"; found 0',
-            'OperatorSpacing.After:5:5' => 'Expecting 1 whitespace after "%"; found 0',
-            'OperatorSpacing.Before:5:5' => 'Expecting 1 whitespace before "%"; found 0',
-            'OperatorSpacing.After:6:5' => 'Expecting 1 whitespace after "//"; found 0',
-            'OperatorSpacing.Before:6:5' => 'Expecting 1 whitespace before "//"; found 0',
-            'OperatorSpacing.After:7:5' => 'Expecting 1 whitespace after "**"; found 0',
-            'OperatorSpacing.Before:7:5' => 'Expecting 1 whitespace before "**"; found 0',
-            'OperatorSpacing.After:8:7' => 'Expecting 1 whitespace after "~"; found 0',
-            'OperatorSpacing.Before:8:7' => 'Expecting 1 whitespace before "~"; found 0',
-            'OperatorSpacing.After:9:10' => 'Expecting 1 whitespace after "?"; found 2',
-            'OperatorSpacing.Before:9:10' => 'Expecting 1 whitespace before "?"; found 2',
-            'OperatorSpacing.After:9:19' => 'Expecting 1 whitespace after ":"; found 2',
-            'OperatorSpacing.Before:9:19' => 'Expecting 1 whitespace before ":"; found 2',
-            'OperatorSpacing.After:10:5' => 'Expecting 1 whitespace after "=="; found 0',
-            'OperatorSpacing.Before:10:5' => 'Expecting 1 whitespace before "=="; found 0',
-            'OperatorSpacing.After:11:4' => 'Expecting 1 whitespace after "not"; found 3',
-            'OperatorSpacing.After:12:11' => 'Expecting 1 whitespace after "and"; found 3',
-            'OperatorSpacing.Before:12:11' => 'Expecting 1 whitespace before "and"; found 2',
-            'OperatorSpacing.After:13:11' => 'Expecting 1 whitespace after "or"; found 3',
-            'OperatorSpacing.Before:13:11' => 'Expecting 1 whitespace before "or"; found 2',
-            'OperatorSpacing.After:14:7' => 'Expecting 1 whitespace after "in"; found 3',
-            'OperatorSpacing.Before:14:7' => 'Expecting 1 whitespace before "in"; found 2',
-            'OperatorSpacing.After:15:7' => 'Expecting 1 whitespace after "is"; found 3',
-            'OperatorSpacing.Before:15:7' => 'Expecting 1 whitespace before "is"; found 2',
-            'OperatorSpacing.After:19:5' => 'Expecting 1 whitespace after "?:"; found 0',
-            'OperatorSpacing.Before:19:5' => 'Expecting 1 whitespace before "?:"; found 0',
-            'OperatorSpacing.After:20:5' => 'Expecting 1 whitespace after "??"; found 0',
-            'OperatorSpacing.Before:20:5' => 'Expecting 1 whitespace before "??"; found 0',
-            'OperatorSpacing.After:22:6' => 'Expecting 1 whitespace after "+"; found 0',
-            'OperatorSpacing.After:33:10' => 'Expecting 1 whitespace after "+"; found 0',
-            'OperatorSpacing.Before:33:10' => 'Expecting 1 whitespace before "+"; found 0',
-            'OperatorSpacing.After:35:13' => 'Expecting 1 whitespace after "starts with"; found 2',
-            'OperatorSpacing.Before:35:13' => 'Expecting 1 whitespace before "starts with"; found 2',
-            'OperatorSpacing.After:36:13' => 'Expecting 1 whitespace after "ends with"; found 2',
-            'OperatorSpacing.Before:36:13' => 'Expecting 1 whitespace before "ends with"; found 2',
-            'OperatorSpacing.After:37:13' => 'Expecting 1 whitespace after "matches"; found 2',
-            'OperatorSpacing.Before:37:13' => 'Expecting 1 whitespace before "matches"; found 2',
+            'OperatorSpacing.After:1:5' => 'Expecting 1 whitespace after "+"; found 0.',
+            'OperatorSpacing.Before:1:5' => 'Expecting 1 whitespace before "+"; found 0.',
+            'OperatorSpacing.After:2:5' => 'Expecting 1 whitespace after "-"; found 0.',
+            'OperatorSpacing.Before:2:5' => 'Expecting 1 whitespace before "-"; found 0.',
+            'OperatorSpacing.After:3:5' => 'Expecting 1 whitespace after "/"; found 0.',
+            'OperatorSpacing.Before:3:5' => 'Expecting 1 whitespace before "/"; found 0.',
+            'OperatorSpacing.After:4:5' => 'Expecting 1 whitespace after "*"; found 0.',
+            'OperatorSpacing.Before:4:5' => 'Expecting 1 whitespace before "*"; found 0.',
+            'OperatorSpacing.After:5:5' => 'Expecting 1 whitespace after "%"; found 0.',
+            'OperatorSpacing.Before:5:5' => 'Expecting 1 whitespace before "%"; found 0.',
+            'OperatorSpacing.After:6:5' => 'Expecting 1 whitespace after "//"; found 0.',
+            'OperatorSpacing.Before:6:5' => 'Expecting 1 whitespace before "//"; found 0.',
+            'OperatorSpacing.After:7:5' => 'Expecting 1 whitespace after "**"; found 0.',
+            'OperatorSpacing.Before:7:5' => 'Expecting 1 whitespace before "**"; found 0.',
+            'OperatorSpacing.After:8:7' => 'Expecting 1 whitespace after "~"; found 0.',
+            'OperatorSpacing.Before:8:7' => 'Expecting 1 whitespace before "~"; found 0.',
+            'OperatorSpacing.After:9:10' => 'Expecting 1 whitespace after "?"; found 2.',
+            'OperatorSpacing.Before:9:10' => 'Expecting 1 whitespace before "?"; found 2.',
+            'OperatorSpacing.After:9:19' => 'Expecting 1 whitespace after ":"; found 2.',
+            'OperatorSpacing.Before:9:19' => 'Expecting 1 whitespace before ":"; found 2.',
+            'OperatorSpacing.After:10:5' => 'Expecting 1 whitespace after "=="; found 0.',
+            'OperatorSpacing.Before:10:5' => 'Expecting 1 whitespace before "=="; found 0.',
+            'OperatorSpacing.After:11:4' => 'Expecting 1 whitespace after "not"; found 3.',
+            'OperatorSpacing.After:12:11' => 'Expecting 1 whitespace after "and"; found 3.',
+            'OperatorSpacing.Before:12:11' => 'Expecting 1 whitespace before "and"; found 2.',
+            'OperatorSpacing.After:13:11' => 'Expecting 1 whitespace after "or"; found 3.',
+            'OperatorSpacing.Before:13:11' => 'Expecting 1 whitespace before "or"; found 2.',
+            'OperatorSpacing.After:14:7' => 'Expecting 1 whitespace after "in"; found 3.',
+            'OperatorSpacing.Before:14:7' => 'Expecting 1 whitespace before "in"; found 2.',
+            'OperatorSpacing.After:15:7' => 'Expecting 1 whitespace after "is"; found 3.',
+            'OperatorSpacing.Before:15:7' => 'Expecting 1 whitespace before "is"; found 2.',
+            'OperatorSpacing.After:19:5' => 'Expecting 1 whitespace after "?:"; found 0.',
+            'OperatorSpacing.Before:19:5' => 'Expecting 1 whitespace before "?:"; found 0.',
+            'OperatorSpacing.After:20:5' => 'Expecting 1 whitespace after "??"; found 0.',
+            'OperatorSpacing.Before:20:5' => 'Expecting 1 whitespace before "??"; found 0.',
+            'OperatorSpacing.After:22:6' => 'Expecting 1 whitespace after "+"; found 0.',
+            'OperatorSpacing.After:33:10' => 'Expecting 1 whitespace after "+"; found 0.',
+            'OperatorSpacing.Before:33:10' => 'Expecting 1 whitespace before "+"; found 0.',
+            'OperatorSpacing.After:35:13' => 'Expecting 1 whitespace after "starts with"; found 2.',
+            'OperatorSpacing.Before:35:13' => 'Expecting 1 whitespace before "starts with"; found 2.',
+            'OperatorSpacing.After:36:13' => 'Expecting 1 whitespace after "ends with"; found 2.',
+            'OperatorSpacing.Before:36:13' => 'Expecting 1 whitespace before "ends with"; found 2.',
+            'OperatorSpacing.After:37:13' => 'Expecting 1 whitespace after "matches"; found 2.',
+            'OperatorSpacing.Before:37:13' => 'Expecting 1 whitespace before "matches"; found 2.',
         ]);
     }
 
diff --git a/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.php b/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.php
index 67707d1..26fa45c 100644
--- a/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.php
+++ b/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.php
@@ -63,39 +63,39 @@ final class PunctuationSpacingRuleTest extends AbstractRuleTestCase
     public function testRule(): void
     {
         $this->checkRule(new PunctuationSpacingRule(), [
-            'PunctuationSpacing.After:3:4' => 'Expecting 0 whitespace after "("; found 1',
-            'PunctuationSpacing.Before:3:10' => 'Expecting 0 whitespace before ")"; found 1',
-            'PunctuationSpacing.After:4:4' => 'Expecting 0 whitespace after "{"; found 1',
-            'PunctuationSpacing.Before:4:10' => 'Expecting 0 whitespace before ":"; found 1',
-            'PunctuationSpacing.Before:4:16' => 'Expecting 0 whitespace before ","; found 1',
-            'PunctuationSpacing.Before:4:22' => 'Expecting 0 whitespace before ":"; found 1',
-            'PunctuationSpacing.Before:4:28' => 'Expecting 0 whitespace before "}"; found 1',
-            'PunctuationSpacing.After:5:12' => 'Expecting 0 whitespace after "["; found 1',
-            'PunctuationSpacing.Before:5:16' => 'Expecting 0 whitespace before ","; found 1',
-            'PunctuationSpacing.Before:5:20' => 'Expecting 0 whitespace before ","; found 1',
-            'PunctuationSpacing.Before:5:24' => 'Expecting 0 whitespace before "]"; found 1',
-            'PunctuationSpacing.After:6:6' => 'Expecting 0 whitespace after "|"; found 1',
-            'PunctuationSpacing.Before:6:6' => 'Expecting 0 whitespace before "|"; found 1',
-            'PunctuationSpacing.Before:7:12' => 'Expecting 0 whitespace before "}"; found 1',
-            'PunctuationSpacing.Before:7:15' => 'Expecting 0 whitespace before "]"; found 1',
+            'PunctuationSpacing.After:3:4' => 'Expecting 0 whitespace after "("; found 1.',
+            'PunctuationSpacing.Before:3:10' => 'Expecting 0 whitespace before ")"; found 1.',
+            'PunctuationSpacing.After:4:4' => 'Expecting 0 whitespace after "{"; found 1.',
+            'PunctuationSpacing.Before:4:10' => 'Expecting 0 whitespace before ":"; found 1.',
+            'PunctuationSpacing.Before:4:16' => 'Expecting 0 whitespace before ","; found 1.',
+            'PunctuationSpacing.Before:4:22' => 'Expecting 0 whitespace before ":"; found 1.',
+            'PunctuationSpacing.Before:4:28' => 'Expecting 0 whitespace before "}"; found 1.',
+            'PunctuationSpacing.After:5:12' => 'Expecting 0 whitespace after "["; found 1.',
+            'PunctuationSpacing.Before:5:16' => 'Expecting 0 whitespace before ","; found 1.',
+            'PunctuationSpacing.Before:5:20' => 'Expecting 0 whitespace before ","; found 1.',
+            'PunctuationSpacing.Before:5:24' => 'Expecting 0 whitespace before "]"; found 1.',
+            'PunctuationSpacing.After:6:6' => 'Expecting 0 whitespace after "|"; found 1.',
+            'PunctuationSpacing.Before:6:6' => 'Expecting 0 whitespace before "|"; found 1.',
+            'PunctuationSpacing.Before:7:12' => 'Expecting 0 whitespace before "}"; found 1.',
+            'PunctuationSpacing.Before:7:15' => 'Expecting 0 whitespace before "]"; found 1.',
         ]);
     }
 
     public function testRuleWithConfiguration(): void
     {
         $this->checkRule(new PunctuationSpacingRule(['}' => null], ['{' => null]), [
-            'PunctuationSpacing.After:3:4' => 'Expecting 0 whitespace after "("; found 1',
-            'PunctuationSpacing.Before:3:10' => 'Expecting 0 whitespace before ")"; found 1',
-            'PunctuationSpacing.Before:4:10' => 'Expecting 0 whitespace before ":"; found 1',
-            'PunctuationSpacing.Before:4:16' => 'Expecting 0 whitespace before ","; found 1',
-            'PunctuationSpacing.Before:4:22' => 'Expecting 0 whitespace before ":"; found 1',
-            'PunctuationSpacing.After:5:12' => 'Expecting 0 whitespace after "["; found 1',
-            'PunctuationSpacing.Before:5:16' => 'Expecting 0 whitespace before ","; found 1',
-            'PunctuationSpacing.Before:5:20' => 'Expecting 0 whitespace before ","; found 1',
-            'PunctuationSpacing.Before:5:24' => 'Expecting 0 whitespace before "]"; found 1',
-            'PunctuationSpacing.After:6:6' => 'Expecting 0 whitespace after "|"; found 1',
-            'PunctuationSpacing.Before:6:6' => 'Expecting 0 whitespace before "|"; found 1',
-            'PunctuationSpacing.Before:7:15' => 'Expecting 0 whitespace before "]"; found 1',
+            'PunctuationSpacing.After:3:4' => 'Expecting 0 whitespace after "("; found 1.',
+            'PunctuationSpacing.Before:3:10' => 'Expecting 0 whitespace before ")"; found 1.',
+            'PunctuationSpacing.Before:4:10' => 'Expecting 0 whitespace before ":"; found 1.',
+            'PunctuationSpacing.Before:4:16' => 'Expecting 0 whitespace before ","; found 1.',
+            'PunctuationSpacing.Before:4:22' => 'Expecting 0 whitespace before ":"; found 1.',
+            'PunctuationSpacing.After:5:12' => 'Expecting 0 whitespace after "["; found 1.',
+            'PunctuationSpacing.Before:5:16' => 'Expecting 0 whitespace before ","; found 1.',
+            'PunctuationSpacing.Before:5:20' => 'Expecting 0 whitespace before ","; found 1.',
+            'PunctuationSpacing.Before:5:24' => 'Expecting 0 whitespace before "]"; found 1.',
+            'PunctuationSpacing.After:6:6' => 'Expecting 0 whitespace after "|"; found 1.',
+            'PunctuationSpacing.Before:6:6' => 'Expecting 0 whitespace before "|"; found 1.',
+            'PunctuationSpacing.Before:7:15' => 'Expecting 0 whitespace before "]"; found 1.',
         ], fixedFilePath: false);
     }
 }