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

Issue:
Support optionality in new `{% types %}` tag
The new `types` tag supports optional variables like this:

```twig
{% types {
    name?: 'string',
} %}
```

But this is currently detected as a problem:
```
 KO ./tests/EndToEnd/Types/optionality.twig
 ------- -----------------------------------------------------
  ERROR   6    | {% types {
          7    |     name?: 'string',
          >>   | Expecting 1 whitespace before "?:"; found 0.
          8    | } %}
 ------- -----------------------------------------------------
```

And fixed like this:
```twig
{% types {
    name ?: 'string|null',
} %}
```

which is not valid.

Pull request:
Support type block
Closes #322

Patch:
diff --git a/src/Rules/Punctuation/PunctuationSpacingRule.php b/src/Rules/Punctuation/PunctuationSpacingRule.php
index 940c95e..1727262 100644
--- a/src/Rules/Punctuation/PunctuationSpacingRule.php
+++ b/src/Rules/Punctuation/PunctuationSpacingRule.php
@@ -23,6 +23,7 @@ final class PunctuationSpacingRule extends AbstractSpacingRule implements Config
         '.' => 0,
         ',' => 0,
         '|' => 0,
+        '?:' => 0,
     ];
     private const DEFAULT_SPACE_AFTER = [
         '(' => 0,
@@ -32,6 +33,7 @@ final class PunctuationSpacingRule extends AbstractSpacingRule implements Config
         '|' => 0,
         ':' => 1,
         ',' => 1,
+        '?:' => 1,
     ];
 
     /** @var array<string, int|null> */
diff --git a/src/Rules/String/HashQuoteRule.php b/src/Rules/String/HashQuoteRule.php
index 665be5e..6a559a6 100644
--- a/src/Rules/String/HashQuoteRule.php
+++ b/src/Rules/String/HashQuoteRule.php
@@ -57,6 +57,12 @@ final class HashQuoteRule extends AbstractFixableRule implements ConfigurableRul
             // so we let the developer chose the right value.
             $fixable = $this->isInteger($value);
         } elseif ($token->isMatching(Token::NAME_TYPE)) {
+            $blockName = $tokens->findPrevious(Token::BLOCK_TOKENS, $tokenIndex - 1);
+            if (false !== $blockName && $tokens->get($blockName)->isMatching(Token::BLOCK_NAME_TYPE, 'types')) {
+                // {% types {'foo': 'int'} %} is not a valid syntax
+                return;
+            }
+
             $fixable = true;
         } else {
             return;
diff --git a/src/Token/Token.php b/src/Token/Token.php
index 6444848..b396c6e 100644
--- a/src/Token/Token.php
+++ b/src/Token/Token.php
@@ -67,6 +67,12 @@ final class Token
         self::COMMENT_END_TYPE => self::COMMENT_END_TYPE,
     ];
 
+    public const BLOCK_TOKENS = [
+        self::BLOCK_START_TYPE => self::BLOCK_START_TYPE,
+        self::BLOCK_NAME_TYPE => self::BLOCK_NAME_TYPE,
+        self::BLOCK_END_TYPE => self::BLOCK_END_TYPE,
+    ];
+
     public function __construct(
         private int|string $type,
         private int $line,
diff --git a/src/Token/Tokenizer.php b/src/Token/Tokenizer.php
index 9136bb6..42520e6 100644
--- a/src/Token/Tokenizer.php
+++ b/src/Token/Tokenizer.php
@@ -601,6 +601,18 @@ final class Tokenizer implements TokenizerInterface
                 }
             }
 
+            $this->pushToken(Token::OPERATOR_TYPE, $operator);
+        } elseif ('?:' === $operator) {
+            if (
+                self::STATE_BLOCK === $this->getState()
+                && 'types' === $this->getStateParam('blockName')
+            ) {
+                // This is an optional variable type declaration instead
+                $this->pushToken(Token::PUNCTUATION_TYPE, $operator);
+
+                return;
+            }
+
             $this->pushToken(Token::OPERATOR_TYPE, $operator);
         } else {
             $this->pushToken(Token::OPERATOR_TYPE, $operator);


Test patch:
diff --git a/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.fixed.twig b/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.fixed.twig
index 6b784c3..0ab9b9d 100644
--- a/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.fixed.twig
+++ b/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.fixed.twig
@@ -51,3 +51,5 @@ Untouch +-/*%==:
 {{ a is not array }}
 {{ a is
          not array or b is foo }}
+
+{% types {foo: 'int', bar?: 'string'} %}
diff --git a/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.twig b/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.twig
index c11e9d4..5e70dd8 100644
--- a/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.twig
+++ b/tests/Rules/Operator/OperatorSpacing/OperatorSpacingRuleTest.twig
@@ -51,3 +51,5 @@ Untouch +-/*%==:
 {{ a  is not  array }}
 {{ a  is
          not  array or b  is  foo }}
+
+{% types {foo: 'int', bar?: 'string'} %}
diff --git a/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.fixed.twig b/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.fixed.twig
index 0f82cce..8a1fafa 100644
--- a/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.fixed.twig
+++ b/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.fixed.twig
@@ -6,3 +6,4 @@
 {{ a|trans }}
 {{ [{a: 1,},] }}
 {{ 'test'[1:2] }}
+{% types {foo: 'int', bar?: 'string'} %}
diff --git a/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.php b/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.php
index 66176b8..b7bda41 100644
--- a/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.php
+++ b/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.php
@@ -21,6 +21,7 @@ final class PunctuationSpacingRuleTest extends AbstractRuleTestCase
                     '.' => 0,
                     ',' => 0,
                     '|' => 0,
+                    '?:' => 0,
                 ],
                 'after' => [
                     '(' => 0,
@@ -30,6 +31,7 @@ final class PunctuationSpacingRuleTest extends AbstractRuleTestCase
                     '|' => 0,
                     ':' => 1,
                     ',' => 1,
+                    '?:' => 1,
                 ],
             ],
             (new PunctuationSpacingRule())->getConfiguration()
@@ -45,6 +47,7 @@ final class PunctuationSpacingRuleTest extends AbstractRuleTestCase
                     '.' => 0,
                     ',' => 0,
                     '|' => 0,
+                    '?:' => 0,
                 ],
                 'after' => [
                     '{' => null,
@@ -54,6 +57,7 @@ final class PunctuationSpacingRuleTest extends AbstractRuleTestCase
                     '|' => 0,
                     ':' => 1,
                     ',' => 1,
+                    '?:' => 1,
                 ],
             ],
             (new PunctuationSpacingRule(['}' => null], ['{' => null]))->getConfiguration()
diff --git a/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.twig b/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.twig
index faf592f..306329d 100644
--- a/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.twig
+++ b/tests/Rules/Punctuation/PunctuationSpacing/PunctuationSpacingRuleTest.twig
@@ -6,3 +6,4 @@
 {{ a | trans }}
 {{ [{a: 1, }, ] }}
 {{ 'test'[1:2] }}
+{% types {foo: 'int', bar?: 'string'} %}
diff --git a/tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig b/tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig
index ac6a6b4..cce497d 100644
--- a/tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig
+++ b/tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig
@@ -17,3 +17,4 @@
 {% set needQuote = {'data-foo': a} %}
 
 {% set namedArgument = foo(bar: true, baz: false) %}
+{% types {foo: 'int', bar?: 'string'} %}
diff --git a/tests/Rules/String/HashQuote/HashQuoteRuleTest.twig b/tests/Rules/String/HashQuote/HashQuoteRuleTest.twig
index c363d3c..76ad223 100644
--- a/tests/Rules/String/HashQuote/HashQuoteRuleTest.twig
+++ b/tests/Rules/String/HashQuote/HashQuoteRuleTest.twig
@@ -17,3 +17,4 @@
 {% set needQuote = {'data-foo': a} %}
 
 {% set namedArgument = foo(bar: true, baz: false) %}
+{% types {foo: 'int', bar?: 'string'} %}
diff --git a/tests/Rules/String/HashQuote/HashQuoteRuleTest.with.fixed.twig b/tests/Rules/String/HashQuote/HashQuoteRuleTest.with.fixed.twig
index 8acba39..feea58d 100644
--- a/tests/Rules/String/HashQuote/HashQuoteRuleTest.with.fixed.twig
+++ b/tests/Rules/String/HashQuote/HashQuoteRuleTest.with.fixed.twig
@@ -17,3 +17,4 @@
 {% set needQuote = {'data-foo': a} %}
 
 {% set namedArgument = foo(bar: true, baz: false) %}
+{% types {foo: 'int', bar?: 'string'} %}
diff --git a/tests/Token/Tokenizer/Fixtures/test16.twig b/tests/Token/Tokenizer/Fixtures/test16.twig
new file mode 100644
index 0000000..3234859
--- /dev/null
+++ b/tests/Token/Tokenizer/Fixtures/test16.twig
@@ -0,0 +1,1 @@
+{% types {foo: 'int', bar?: 'string'} %}
diff --git a/tests/Token/Tokenizer/TokenizerTest.php b/tests/Token/Tokenizer/TokenizerTest.php
index 932a729..db59ad8 100644
--- a/tests/Token/Tokenizer/TokenizerTest.php
+++ b/tests/Token/Tokenizer/TokenizerTest.php
@@ -773,6 +773,32 @@ final class TokenizerTest extends TestCase
                 78 => Token::EOF_TYPE,
             ],
         ];
+
+        yield [
+            __DIR__.'/Fixtures/test16.twig',
+            [
+                0 => Token::BLOCK_START_TYPE,
+                1 => Token::WHITESPACE_TYPE,
+                2 => Token::BLOCK_NAME_TYPE,
+                3 => Token::WHITESPACE_TYPE,
+                4 => Token::PUNCTUATION_TYPE,
+                5 => Token::NAME_TYPE,
+                6 => Token::PUNCTUATION_TYPE,
+                7 => Token::WHITESPACE_TYPE,
+                8 => Token::STRING_TYPE,
+                9 => Token::PUNCTUATION_TYPE,
+                10 => Token::WHITESPACE_TYPE,
+                11 => Token::NAME_TYPE,
+                12 => Token::PUNCTUATION_TYPE,
+                13 => Token::WHITESPACE_TYPE,
+                14 => Token::STRING_TYPE,
+                15 => Token::PUNCTUATION_TYPE,
+                16 => Token::WHITESPACE_TYPE,
+                17 => Token::BLOCK_END_TYPE,
+                18 => Token::EOL_TYPE,
+                19 => Token::EOF_TYPE,
+            ],
+        ];
     }
 
     /**