Repository: PHP-CS-Fixer/PHP-CS-Fixer (php)

Issue:
Rule `declare_strict_types` does not work on files that start with a "hashbang" line
## Bug report

### Description

The rule `declare_strict_types` does not work on files that start with a "hashbang" line:

```
#!/usr/bin/env php
<?php
// the rest of the file
```

I discovered the bug when I tried to reformat a `bin/console` file generated by Symfony CLI,
but further testing soon revealed that the rule simply ignores "mixed content" files
that contain any non-PHP lines before the `<php` opening tag.

While I understand that the behaviour might be intentional (to avoid corrupting files
where the `declare` statement would have no effect or wrong effect), I also think that
the hashbang line should be an exception and files that start with it but contain no other
non-PHP content should be treated as "pure PHP" files and modified by the rule.

### Runtime version

```
PHP CS Fixer 3.43.0 Three Keys by Fabien Potencier and Dariusz Ruminski.
PHP runtime: 8.3.1
```

### Used command

`vendor/bin/php-cs-fixer -vvv fix`

### Configuration file

```php
<?php
$rules = [
    'declare_strict_types' => true,
];

$finder = new PhpCsFixer\Finder();
$finder->in(__DIR__);

$config = new PhpCsFixer\Config();
$config->setRiskyAllowed(true);
$config->setRules($rules);
$config->setFinder($finder);

return $config;
```

### Code snippet that reproduces the problem

```php
#!/usr/bin/env php
<?php
echo 'PHP-CS-Fixer should add declare statement above!';
```

Expected result:

```php
#!/usr/bin/env php
<?php declare(strict_types=1);
echo 'PHP-CS-Fixer should add declare statement above!';
```

Actual result: file is not changed at all.

Here is a simple repository that demonstrates the bug:
<https://github.com/krzysztof-sikorski/php-cs-fixer-declare-strict-types-bug>

Just pull the code, execute `composer install`, and then `vendor/bin/php-cs-fixer -vvv fix`.


Pull request description:
fix: Support shebang in fixers operating on PHP opening tag
fix #7634 (and #7681 duplicate)

Patch:
diff --git a/dev-tools/doc.php b/dev-tools/doc.php
index e0a1f2a7c..2197a059d 100755
--- a/dev-tools/doc.php
+++ b/dev-tools/doc.php
@@ -1,6 +1,8 @@
 #!/usr/bin/env php
 <?php
 
+declare(strict_types=1);
+
 /*
  * This file is part of PHP CS Fixer.
  *
diff --git a/dev-tools/info-extractor.php b/dev-tools/info-extractor.php
index 2c80bd034..107812f53 100755
--- a/dev-tools/info-extractor.php
+++ b/dev-tools/info-extractor.php
@@ -1,6 +1,8 @@
 #!/usr/bin/env php
 <?php
 
+declare(strict_types=1);
+
 /*
  * This file is part of PHP CS Fixer.
  *
diff --git a/src/Fixer/Comment/HeaderCommentFixer.php b/src/Fixer/Comment/HeaderCommentFixer.php
index e6da49d83..1915cf235 100644
--- a/src/Fixer/Comment/HeaderCommentFixer.php
+++ b/src/Fixer/Comment/HeaderCommentFixer.php
@@ -112,7 +112,7 @@ public function getDefinition(): FixerDefinitionInterface
 
     public function isCandidate(Tokens $tokens): bool
     {
-        return $tokens->isMonolithicPhp();
+        return $tokens->isMonolithicPhp() && !$tokens->isTokenKindFound(T_OPEN_TAG_WITH_ECHO);
     }
 
     /**
@@ -266,11 +266,7 @@ private function findHeaderCommentCurrentIndex(Tokens $tokens, int $headerNewInd
      */
     private function findHeaderCommentInsertionIndex(Tokens $tokens, string $location): int
     {
-        $openTagIndex = $tokens[0]->isGivenKind(T_OPEN_TAG) ? 0 : $tokens->getNextTokenOfKind(0, [[T_OPEN_TAG]]);
-
-        if (null === $openTagIndex) {
-            return 1;
-        }
+        $openTagIndex = $tokens[0]->isGivenKind(T_INLINE_HTML) ? 1 : 0;
 
         if ('after_open' === $location) {
             return $openTagIndex + 1;
diff --git a/src/Fixer/PhpTag/BlankLineAfterOpeningTagFixer.php b/src/Fixer/PhpTag/BlankLineAfterOpeningTagFixer.php
index 8c8dbad73..d4af8196e 100644
--- a/src/Fixer/PhpTag/BlankLineAfterOpeningTagFixer.php
+++ b/src/Fixer/PhpTag/BlankLineAfterOpeningTagFixer.php
@@ -48,23 +48,16 @@ public function getPriority(): int
 
     public function isCandidate(Tokens $tokens): bool
     {
-        return $tokens->isTokenKindFound(T_OPEN_TAG);
+        return $tokens->isMonolithicPhp() && !$tokens->isTokenKindFound(T_OPEN_TAG_WITH_ECHO);
     }
 
     protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
     {
         $lineEnding = $this->whitespacesConfig->getLineEnding();
 
-        // ignore files with short open tag and ignore non-monolithic files
-        if (!$tokens[0]->isGivenKind(T_OPEN_TAG) || !$tokens->isMonolithicPhp()) {
-            return;
-        }
-
         $newlineFound = false;
-
-        /** @var Token $token */
         foreach ($tokens as $token) {
-            if ($token->isWhitespace() && str_contains($token->getContent(), "\n")) {
+            if (($token->isWhitespace() || $token->isGivenKind(T_OPEN_TAG)) && str_contains($token->getContent(), "\n")) {
                 $newlineFound = true;
 
                 break;
@@ -76,17 +69,19 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
             return;
         }
 
-        $token = $tokens[0];
+        $openTagIndex = $tokens[0]->isGivenKind(T_INLINE_HTML) ? 1 : 0;
+        $token = $tokens[$openTagIndex];
 
         if (!str_contains($token->getContent(), "\n")) {
-            $tokens[0] = new Token([$token->getId(), rtrim($token->getContent()).$lineEnding]);
+            $tokens[$openTagIndex] = new Token([$token->getId(), rtrim($token->getContent()).$lineEnding]);
         }
 
-        if (!str_contains($tokens[1]->getContent(), "\n")) {
-            if ($tokens[1]->isWhitespace()) {
-                $tokens[1] = new Token([T_WHITESPACE, $lineEnding.$tokens[1]->getContent()]);
+        $newLineIndex = $openTagIndex + 1;
+        if (isset($tokens[$newLineIndex]) && !str_contains($tokens[$newLineIndex]->getContent(), "\n")) {
+            if ($tokens[$newLineIndex]->isWhitespace()) {
+                $tokens[$newLineIndex] = new Token([T_WHITESPACE, $lineEnding.$tokens[$newLineIndex]->getContent()]);
             } else {
-                $tokens->insertAt(1, new Token([T_WHITESPACE, $lineEnding]));
+                $tokens->insertAt($newLineIndex, new Token([T_WHITESPACE, $lineEnding]));
             }
         }
     }
diff --git a/src/Fixer/PhpTag/LinebreakAfterOpeningTagFixer.php b/src/Fixer/PhpTag/LinebreakAfterOpeningTagFixer.php
index 5be68fab6..2a8271613 100644
--- a/src/Fixer/PhpTag/LinebreakAfterOpeningTagFixer.php
+++ b/src/Fixer/PhpTag/LinebreakAfterOpeningTagFixer.php
@@ -37,24 +37,21 @@ public function getDefinition(): FixerDefinitionInterface
 
     public function isCandidate(Tokens $tokens): bool
     {
-        return $tokens->isTokenKindFound(T_OPEN_TAG);
+        return $tokens->isMonolithicPhp() && !$tokens->isTokenKindFound(T_OPEN_TAG_WITH_ECHO);
     }
 
     protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
     {
-        // ignore files with short open tag and ignore non-monolithic files
-        if (!$tokens[0]->isGivenKind(T_OPEN_TAG) || !$tokens->isMonolithicPhp()) {
-            return;
-        }
+        $openTagIndex = $tokens[0]->isGivenKind(T_INLINE_HTML) ? 1 : 0;
 
         // ignore if linebreak already present
-        if (str_contains($tokens[0]->getContent(), "\n")) {
+        if (str_contains($tokens[$openTagIndex]->getContent(), "\n")) {
             return;
         }
 
         $newlineFound = false;
         foreach ($tokens as $token) {
-            if ($token->isWhitespace() && str_contains($token->getContent(), "\n")) {
+            if (($token->isWhitespace() || $token->isGivenKind(T_OPEN_TAG)) && str_contains($token->getContent(), "\n")) {
                 $newlineFound = true;
 
                 break;
@@ -66,6 +63,6 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
             return;
         }
 
-        $tokens[0] = new Token([T_OPEN_TAG, rtrim($tokens[0]->getContent()).$this->whitespacesConfig->getLineEnding()]);
+        $tokens[$openTagIndex] = new Token([T_OPEN_TAG, rtrim($tokens[$openTagIndex]->getContent()).$this->whitespacesConfig->getLineEnding()]);
     }
 }
diff --git a/src/Fixer/PhpTag/NoClosingTagFixer.php b/src/Fixer/PhpTag/NoClosingTagFixer.php
index 8921950af..14c40c037 100644
--- a/src/Fixer/PhpTag/NoClosingTagFixer.php
+++ b/src/Fixer/PhpTag/NoClosingTagFixer.php
@@ -38,15 +38,11 @@ public function getDefinition(): FixerDefinitionInterface
 
     public function isCandidate(Tokens $tokens): bool
     {
-        return $tokens->isTokenKindFound(T_CLOSE_TAG);
+        return \count($tokens) >= 2 && $tokens->isMonolithicPhp() && $tokens->isTokenKindFound(T_CLOSE_TAG);
     }
 
     protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
     {
-        if (\count($tokens) < 2 || !$tokens->isMonolithicPhp() || !$tokens->isTokenKindFound(T_CLOSE_TAG)) {
-            return;
-        }
-
         $closeTags = $tokens->findGivenKind(T_CLOSE_TAG);
         $index = array_key_first($closeTags);
 
diff --git a/src/Fixer/Strict/DeclareStrictTypesFixer.php b/src/Fixer/Strict/DeclareStrictTypesFixer.php
index 4a83a9def..7e8172c24 100644
--- a/src/Fixer/Strict/DeclareStrictTypesFixer.php
+++ b/src/Fixer/Strict/DeclareStrictTypesFixer.php
@@ -53,7 +53,7 @@ public function getPriority(): int
 
     public function isCandidate(Tokens $tokens): bool
     {
-        return isset($tokens[0]) && $tokens[0]->isGivenKind(T_OPEN_TAG);
+        return $tokens->isMonolithicPhp() && !$tokens->isTokenKindFound(T_OPEN_TAG_WITH_ECHO);
     }
 
     public function isRisky(): bool
@@ -63,17 +63,11 @@ public function isRisky(): bool
 
     protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
     {
-        // check if the declaration is already done
-        $searchIndex = $tokens->getNextMeaningfulToken(0);
-        if (null === $searchIndex) {
-            $this->insertSequence($tokens); // declaration not found, insert one
+        $openTagIndex = $tokens[0]->isGivenKind(T_INLINE_HTML) ? 1 : 0;
 
-            return;
-        }
-
-        $sequenceLocation = $tokens->findSequence([[T_DECLARE, 'declare'], '(', [T_STRING, 'strict_types'], '=', [T_LNUMBER], ')'], $searchIndex, null, false);
+        $sequenceLocation = $tokens->findSequence([[T_DECLARE, 'declare'], '(', [T_STRING, 'strict_types'], '=', [T_LNUMBER], ')'], $openTagIndex, null, false);
         if (null === $sequenceLocation) {
-            $this->insertSequence($tokens); // declaration not found, insert one
+            $this->insertSequence($openTagIndex, $tokens); // declaration not found, insert one
 
             return;
         }
@@ -102,7 +96,7 @@ private function fixStrictTypesCasingAndValue(Tokens $tokens, array $sequence):
         }
     }
 
-    private function insertSequence(Tokens $tokens): void
+    private function insertSequence(int $openTagIndex, Tokens $tokens): void
     {
         $sequence = [
             new Token([T_DECLARE, 'declare']),
@@ -113,28 +107,26 @@ private function insertSequence(Tokens $tokens): void
             new Token(')'),
             new Token(';'),
         ];
-        $endIndex = \count($sequence);
+        $nextIndex = $openTagIndex + \count($sequence) + 1;
 
-        $tokens->insertAt(1, $sequence);
+        $tokens->insertAt($openTagIndex + 1, $sequence);
 
-        // start index of the sequence is always 1 here, 0 is always open tag
-        // transform "<?php\n" to "<?php " if needed
-        if (str_contains($tokens[0]->getContent(), "\n")) {
-            $tokens[0] = new Token([$tokens[0]->getId(), trim($tokens[0]->getContent()).' ']);
+        // transform "<?php" or "<?php\n" to "<?php " if needed
+        $content = $tokens[$openTagIndex]->getContent();
+        if (!str_contains($content, ' ') || str_contains($content, "\n")) {
+            $tokens[$openTagIndex] = new Token([$tokens[$openTagIndex]->getId(), trim($tokens[$openTagIndex]->getContent()).' ']);
         }
 
-        if ($endIndex === \count($tokens) - 1) {
+        if (\count($tokens) === $nextIndex) {
             return; // no more tokens after sequence, single_blank_line_at_eof might add a line
         }
 
         $lineEnding = $this->whitespacesConfig->getLineEnding();
-        if (!$tokens[1 + $endIndex]->isWhitespace()) {
-            $tokens->insertAt(1 + $endIndex, new Token([T_WHITESPACE, $lineEnding]));
-
-            return;
+        if ($tokens[$nextIndex]->isWhitespace()) {
+            $content = $tokens[$nextIndex]->getContent();
+            $tokens[$nextIndex] = new Token([T_WHITESPACE, $lineEnding.ltrim($content, " \t")]);
+        } else {
+            $tokens->insertAt($nextIndex, new Token([T_WHITESPACE, $lineEnding]));
         }
-
-        $content = $tokens[1 + $endIndex]->getContent();
-        $tokens[1 + $endIndex] = new Token([T_WHITESPACE, $lineEnding.ltrim($content, " \t")]);
     }
 }
diff --git a/src/Tokenizer/Tokens.php b/src/Tokenizer/Tokens.php
index 98e414cfc..de44ebe5a 100644
--- a/src/Tokenizer/Tokens.php
+++ b/src/Tokenizer/Tokens.php
@@ -1138,19 +1138,12 @@ public function clearRange(int $indexStart, int $indexEnd): void
      */
     public function isMonolithicPhp(): bool
     {
-        if (0 === $this->count()) {
+        if (1 !== ($this->countTokenKind(T_OPEN_TAG) + $this->countTokenKind(T_OPEN_TAG_WITH_ECHO))) {
             return false;
         }
 
-        if ($this->countTokenKind(T_INLINE_HTML) > 1) {
-            return false;
-        }
-
-        if (1 === $this->countTokenKind(T_INLINE_HTML)) {
-            return Preg::match('/^#!.+$/', $this[0]->getContent());
-        }
-
-        return 1 === ($this->countTokenKind(T_OPEN_TAG) + $this->countTokenKind(T_OPEN_TAG_WITH_ECHO));
+        return 0 === $this->countTokenKind(T_INLINE_HTML)
+            || (1 === $this->countTokenKind(T_INLINE_HTML) && Preg::match('/^#!.+$/', $this[0]->getContent()));
     }
 
     /**


Test patch:
diff --git a/tests/Fixer/PhpTag/BlankLineAfterOpeningTagFixerTest.php b/tests/Fixer/PhpTag/BlankLineAfterOpeningTagFixerTest.php
index a23ab62ca..438b12ac7 100644
--- a/tests/Fixer/PhpTag/BlankLineAfterOpeningTagFixerTest.php
+++ b/tests/Fixer/PhpTag/BlankLineAfterOpeningTagFixerTest.php
@@ -40,10 +40,8 @@ public static function provideFixCases(): iterable
         yield [
             '<?php
 
-    $a = 0;
     echo 1;',
             '<?php
-    $a = 0;
     echo 1;',
         ];
 
@@ -138,6 +136,28 @@ class SomeClass
 $foo = $bar;
 ?>',
         ];
+
+        yield 'empty file with open tag without new line' => [
+            '<?php',
+        ];
+
+        yield 'empty file with open tag with new line' => [
+            "<?php\n",
+        ];
+
+        yield 'file with shebang' => [
+            <<<'EOD'
+                #!x
+                <?php
+
+                echo 1;
+                EOD,
+            <<<'EOD'
+                #!x
+                <?php
+                echo 1;
+                EOD,
+        ];
     }
 
     /**
diff --git a/tests/Fixer/PhpTag/LinebreakAfterOpeningTagFixerTest.php b/tests/Fixer/PhpTag/LinebreakAfterOpeningTagFixerTest.php
index 5b0e48570..ba9ed964e 100644
--- a/tests/Fixer/PhpTag/LinebreakAfterOpeningTagFixerTest.php
+++ b/tests/Fixer/PhpTag/LinebreakAfterOpeningTagFixerTest.php
@@ -91,6 +91,20 @@ public static function provideFixCases(): iterable
 // linebreak already present in file with Windows line endings
 '),
         ];
+
+        yield 'file with shebang' => [
+            <<<'EOD'
+                #!x
+                <?php
+                echo 1;
+                echo 2;
+                EOD,
+            <<<'EOD'
+                #!x
+                <?php echo 1;
+                echo 2;
+                EOD,
+        ];
     }
 
     /**
diff --git a/tests/Fixer/Strict/DeclareStrictTypesFixerTest.php b/tests/Fixer/Strict/DeclareStrictTypesFixerTest.php
index 709d50456..47a5f48a3 100644
--- a/tests/Fixer/Strict/DeclareStrictTypesFixerTest.php
+++ b/tests/Fixer/Strict/DeclareStrictTypesFixerTest.php
@@ -49,8 +49,17 @@ class A {
             '<?php declare/* A b C*/(strict_types=1);',
         ];
 
-        yield [
-            '<?php /**/ /**/ deClarE  (strict_types=1)    ?>Test',
+        yield 'monolithic file with closing tag' => [
+            '<?php /**/ /**/ deClarE  (strict_types=1)    ?>',
+            '<?php /**/ /**/ deClarE  (STRICT_TYPES=1)    ?>',
+        ];
+
+        yield 'monolithic file with closing tag and extra new line' => [
+            '<?php /**/ /**/ deClarE  (strict_types=1)    ?>'."\n",
+            '<?php /**/ /**/ deClarE  (STRICT_TYPES=1)    ?>'."\n",
+        ];
+
+        yield 'monolithic file with closing tag and extra content' => [
             '<?php /**/ /**/ deClarE  (STRICT_TYPES=1)    ?>Test',
         ];
 
@@ -133,6 +142,49 @@ class A {
         yield ['  <?php echo 123;']; // first statement must be an open tag
 
         yield ['<?= 123;']; // first token open with echo is not fixed
+
+        yield 'empty file /wo open tag' => [
+            '',
+        ];
+
+        yield 'empty file /w open tag' => [
+            '<?php declare(strict_types=1);',
+            '<?php',
+        ];
+
+        yield 'non-empty file /wo open tag' => [
+            'x',
+        ];
+
+        yield 'non-empty file /w open tag' => [
+            'x<?php',
+        ];
+
+        yield 'file with shebang /w open tag' => [
+            <<<'EOD'
+                #!x
+                <?php declare(strict_types=1);
+                EOD,
+            <<<'EOD'
+                #!x
+                <?php
+                EOD,
+        ];
+
+        yield 'file with shebang /wo open tag' => [
+            <<<'EOD'
+                #!x
+                y
+                EOD,
+        ];
+
+        yield 'file with shebang not followed by open tag' => [
+            <<<'EOD'
+                #!x
+                #!not_a_shebang
+                <?php
+                EOD,
+        ];
     }
 
     /**